Generator ∕ yield 与 async ∕ await

异步编程对JS的重要性应该是不言而喻了的,作为单线程的编程语言,如果没有异步编程,体验不要太酸爽

比如我们有一个读取文件并进行处理的任务。如果有异步编程,先是向操作系统发出请求,要求读取文件。然后,程序会去执行其他任务,等到操作系统返回文件,再接着处理文件的任务

相反的,如果没有异步函数,上述编程就变成了同步的执行方式。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着

Promise

从 Promise 开始,JavaScript 就在引入新功能来实现更简单的方法处理异步编程,帮助我们远离回调地狱

回调地狱也就是多个回调函数嵌套带来的问题。比如读取文件A之后,再读取文件B

fs.readFile(fileA, function (err, data) {
  fs.readFile(fileB, function (err, data) {
    // ...
  })
})

很明显,在读取多个文件的情况下,代码就会出现多重嵌套

一个很明显的特点是,代码不是纵向发展,而是横向发展的,造成的结果是代码混乱,无法管理

为了解决这个问题,就出现了 Promise

注意,它不是新的语法功能,而是一种新的写法,这里就不具体介绍它的实现了,有兴趣的小伙伴可以翻阅我的另一篇博客分层解析 Promise 的实现原理

Promise 允许将回调函数的横向加载,改成纵向加载

回到上面那个读取文件的问题,用 Promise 实现就会简洁很多

var readFile = require('fs-readfile-promise')

readFile(fileA)
.then(function(data){
  console.log(data.toString())
})
.then(function(){
  return readFile(fileB)
})
.then(function(data){
  console.log(data.toString())
})
.catch(function(err) {
  console.log(err)
})

Promise 提供 then 方法加载回调函数,catch方法捕捉执行过程中抛出的错误

但 Promise 也有它的缺点,比如代码冗余,原来的函数需要被 Promise 包装,这样不管什么操作,都会出现一堆 then ,语义也不是明确

那有没有更好的写法呢?

Generator / yield

ES6 推出了 Generator / yield 两个关键字,使用 Generator 可以很方便的帮助我们建立一个处理 Promise 的解释器,比较重要的是

第一,Generator (生成器)函数最大的特点就是可以交出函数的执行权(即暂停执行),yield 表达式就是暂停的标志

第二,控制 Generator 函数的执行/暂停的方法是 next() 方法,它是由 Generator 函数返回的一个迭代器提供的

接下来我们对这两点进行详细说明

执行过程

生成器函数的语法为 function*,在其函数体内部可以使用 yield 关键字

看到这里,有的小伙伴可能会有疑问,生成器函数也是函数,那他和普通函数有什么区别呢?我们先来看一个简单的例子

假设你已经引入了fetch文件 <script src="https://cdn.bootcss.com/fetch/2.0.4/fetch.js"></script>

function* gen(num){
  console.log('first')
  var result = yield num + 2
  console.log('second')
  return result
}

var g = gen(1)

当我们这样执行代码的时候,会发现控制台根本没有输出,为什么呢?这也就是生成器函数和普通函数的区别,它可以交出执行权,即暂停执行。yield 表达式就是暂停标志,再次强调上述第一点

那应该怎样让暂停的代码继续执行了,也就是上述第二点,可以通过 next 方法。执行结果是一个包含 value 和 done 属性的对象

接着上面的 demo,我们执行 console.log(g.next()),就会出现

// first
// { value: 3, done: false }

接着再执行 console.log(g.next())

// second
// { value: undefined, done: true }

上面代码一共调用了两次 next 方法

第一次调用, Generator 函数开始执行,直到遇到第一个 yield 表达式为止。next 方法返回一个对象,它的 value 属性就是当前 yield 表达式的值,done 属性的值 false,表示遍历还没有结束

第二次调用,Generator 函数从上次 yield 表达式停下的地方,一直执行到下一个 yield 表达式或者 return 语句(如果没有,就执行到函数结束)。next 方法返回的对象的 value 属性就是当前 yield 表达式的值或者 return 语句后面的表达式的值(如果都没有,则 value 属性的值为 undefined ),done 属性的值 true ,表示遍历已经结束。

next 方法的作用是分阶段执行 Generator 函数。每次调用 next 方法,会返回一个对象,表示当前阶段的信息( value 属性和 done 属性)。value 属性是 yield 语句后面表达式的值,表示当前阶段的值;done 属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段

数据交换和错误处理

我们比较熟悉的是 Generator 函数可以暂停执行和恢复执行,这也是它能封装异步任务的根本原因

除此之外,还有两个作为异步编程完整方案的特性:函数体内外的数据交换和错误处理机制

数据交换

Generator 函数向外输出数据:next 方法返回值的 value 属性

Generator 函数体内输入数据: 通过 next 方法接受参数

还是上面的例子,修改一下代码

function* gen(num){
  console.log('first')
  var result = yield num + 2
  console.log('second')
  return result
}

var g = gen(1)
console.log(g.next())
console.log(g.next(2))

控制台就打印出了

// first
// { value: 3, done: false }
// second
// { value: 2, done: true }

上面代码中,第一个 next 方法的 value 属性,返回表达式 num + 2 的值(3)。第二个 next 方法带有参数 2,这个参数可以传入 Generator 函数,作为上个阶段异步任务的返回结果,被函数体内的变量 result 接收。因此,这一步的 value 属性,返回的就是 2(变量 result 的值)

错误处理机制

Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误

function* gen(x){
  try {
    var y = yield x + 2
  } catch (e){ 
    console.log(e)
  }
  return y
}

var g = gen(1)
g.next()
g.throw('出错了'

上面代码的最后一行,Generator 函数体外,使用指针对象的 throw 方法抛出的错误,可以被函数体内的 try ... catch 代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的

执行异步任务

手动执行

手动执行其实就是用 then 方法,层层添加回调函数。接下来我们看一个实际的异步例子

function* gen(){
  var result1 = yield fetch('https://api.github.com/users/github')
  var result2 = yield fetch('https://api.github.com/users/github')
  console.log('result1',result1)
  console.log('result2',result2)
  return result2
}
// 手动执行Generator 函数
var g = gen()
g.next(1).value.then(function(data){
  console.log('第一次执行g.next()',data)
  g.next(data).value.then(function(data){
    console.log('第二次执行g.next(),并将data传给result1',data)
    return data.json()
  }).then(function(data){
    console.log('解析出第二次结果的json数据',data)
    console.log('将data传入给result2',g.next(data))
    console.log('已执行结束',g.next())
  })
})

上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从 JSON 格式的数据解析信息

首先执行 Generator 函数,获取遍历器对象,然后使用 next 方法,执行异步任务的第一阶段。由于 Fetch 模块返回的是一个 Promise 对象,因此要用 then 方法调用下一个next 方法

假如我们同时执行的异步任务相互不依赖,这里用一个同步任务来模拟异步任务执行快的过程,如下

function* gen(num){
  var result1 = yield fetch('https://api.github.com/users/github')
  var result2 = yield num + 2
  console.log('result1',result1)
  console.log('result2',result2)
  return result2
}

var g = gen(1)
g.next().value.then(function(data){
  console.log('data',data)
})
console.log(g.next('result1'))
console.log(g.next('result2'))

打印出来的结果

image

相信大家已经对 next 方法的运行逻辑明白的差不多了,大致也就是

  1. 每调用一次 g.next() 方法都会暂停执行 yield 后面的操作,并将 yield 后面的表达式的值作为返回对象中 value 的值

  2. 下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式

  3. 如果没有再遇到新的 yield 表达式,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值

需要注意的是,yield 表达式后面的表达式,只有当调用 next 方法、内部指针指向该语句时才会执行

可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段),那应该怎样自动化异步任务的流程管理呢

Generator 函数就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。

两种方法可以做到这一点。

  1. 回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。

  2. Promise 对象。将异步操作包装成 Promise 对象,用 then 方法交回执行权。

这里就不着重讲回调函数的方法了,有兴趣的小伙伴可以参考 Thunk 函数的含义和用法

基于 Promise 对象的自动执行

function* gen(){
  var result = yield fetch('https://api.github.com/users/github')
  console.log('result',result)
  return result
}
function run(gen){
  var g = gen()

  function next(data){
    var result = g.next(data)
    if (result.done) return result.value
    result.value.then(function(data){
      return data.json()
    }).then(function(data){
      next(data)
    })
  }

  next()
}

run(gen)

上面代码中,只要 Generator 函数还没执行到最后一步,next 函数就调用自身,以此实现自动执行

co 函数库

co 模块是 nodejs 社区著名的TJ大神写的一个小工具,用于 Generato r函数的自动执行。co 是上面那个自动执行器的扩展,官网上面有具体的 demo

image

源码解析可以参考co 函数库的含义和用法

首先,co 函数接受 Generator 函数作为参数,返回一个 Promise 对象

function co(gen) {
  var ctx = this

  return new Promise(function(resolve, reject) {
  })
}

在返回的 Promise 对象里面,co 先检查参数 gen 是否为 Generator 函数。如果是,就执行该函数,得到一个内部指针对象;如果不是就返回,并将 Promise 对象的状态改为 resolved

function co(gen) {
  var ctx = this

  return new Promise(function(resolve, reject) {
    if (typeof gen === 'function') gen = gen.call(ctx)
    if (!gen || typeof gen.next !== 'function') return resolve(gen)
  })
}

接着,co 将 Generator 函数的内部指针对象的 next 方法,包装成 onFulefilled 函数。这主要是为了能够捕捉抛出的错误

function co(gen) {
  var ctx = this

  return new Promise(function(resolve, reject) {
    if (typeof gen === 'function') gen = gen.call(ctx)
    if (!gen || typeof gen.next !== 'function') return resolve(gen)

    onFulfilled()
    function onFulfilled(res) {
      var ret
      try {
        ret = gen.next(res)
      } catch (e) {
        return reject(e)
      }
      next(ret)
    }    
  })
}

最后,就是关键的 next 函数,它会反复调用自身

function next(ret) {
  if (ret.done) return resolve(ret.value)
  var value = toPromise.call(ctx, ret.value)
  if (value && isPromise(value)) return value.then(onFulfilled, onRejected)
  return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
        + 'but the following object was passed: "' + String(ret.value) + '"'))
    }
})

> 上面代码中,next 函数的内部代码,一共只有四行命令

> 第一行,检查当前是否为 Generator 函数的最后一步,如果是就返回。

> 第二行,确保每一步的返回值,是 Promise 对象。

> 第三行,使用 then 方法,为返回值加上回调函数,然后通过 onFulfilled 函数再次调用 next 函数。

> 第四行,在参数不符合要求的情况下(参数非 Thunk 函数和 Promise 对象),将 Promise 对象的状态改为 rejected,从而终止执行。

下面是一个 Generator 函数,用于依次读取两个文件

var fs = require('fs')

var readFile = function (fileName){
  return new Promise(function (resolve, reject){
    fs.readFile(fileName, function(error, data){
      if (error) reject(error)
      resolve(data)
    })
  })
}

var gen = function* () {
  var f1 = yield readFile('/etc/fstab')
  var f2 = yield readFile('/etc/shells')
  console.log(f1.toString())
  console.log(f2.toString())
}

var co = require('co')
co(gen)

co模块可以让你不用编写Generator函数的执行器。Generator函数只要传入co函数,就会自动执行。co函数返回一个Promise对象,因此可以用then方法添加回调函数。

co(gen).then(function () {
  console.log('Generator 函数执行完成')
})

co模块的原理:其实就是将两种自动执行器(Thunk函数和Promise对象),包装成一个模块。使用co的前提条件是,Generator函数的yield命令后面,只能是Thunk函数或Promise对象。如果数组或对象的成员,全部都是Promise对象,也可以使用co(co v4.0版以后,yield命令后面只能是Promise对象,不再支持Thunk函数)

co 支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成,才进行下一步。这时,要把并发的操作都放在数组或对象里面

async/await

在 ES7 ,得到了 Generator/yield 这样的语法,可以让我们以接近编写同步代码的方式来编写异步代码(无需使用.then()或者回调函数)

目前,它仍处于提案阶段,但是转码器Babel和regenerator都已经支持

async函数可以说是目前异步操作最好的解决方案,是对Generator函数的升级和改进

是什么

async 函数就是 Generator 函数的语法糖

注意:await只能用在async函数中

上面那个读取两个文件的例子,写成 async 函数,就是下面这样

var asyncReadFile = async function (){
  var f1 = await readFile('/etc/fstab')
  var f2 = await readFile('/etc/shells')
  console.log(f1.toString())
  console.log(f2.toString())
}

可以发现,async 函数就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await

优点

  1. 内置执行器。Generator 函数的执行必须靠执行器,所以才有了 co 函数库,而 async 函数自带执行器。也就是说,async 函数的执行,与普通函数一模一样,只要一行。
var result = asyncReadFile()
  1. 更好的语义。 async 和 await,比起星号和 yield,语义更清楚了。async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果

  2. 更广的适用性。 co 函数库约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以跟 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)

async 函数的实现

async 函数的实现,就是将 Generator 函数和自动执行器,包装在一个函数里

async function fn(args){
  // ...
}

// 等同于

function fn(args){ 
  return spawn(function*() {
    // ...
  })
}

所有的 async 函数都可以写成上面的第二种形式,其中的 spawn 函数就是自动执行器

async 函数的用法

同 Generator 函数一样,async 函数返回一个 Promise 对象,可以使用 then 方法添加回调函数。当函数执行的时候,一旦遇到 await 就会先返回,等到触发的异步操作完成,再接着执行函数体内后面的语句

var url = 'https://api.github.com/users/github'

async function getUrl(url) {
  var result1 = await fetch(url)
  var result2 = await fetch(url)
  console.log('result1',result1)
  console.log('result2',result2)
  return result2.json()
}

!async function() {
  var result = await getUrl(url)
  console.log(result)
}()

// getUrl(url).then(function(data){
//   console.log('data',data)
// })

控制台打印结果为

image

需要注意的是

  1. await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try...catch 代码块中
var url = 'https://api.github.com/users/github'

async function getUrl(url) {
  try {
    await fetch(url)
  } catch (err) {
    console.log(err)
  }
}

!async function() {
  var result = await getUrl(url)
  console.log(result)
}()
  1. await 命令只能用在 async 函数之中,如果用在普通函数,就会报错

  2. 如果这两个是独立的异步操作,可以让它们同时触发

let one = await getOne()
let two = await getTwo()

let [foo, bar] = await Promise.all([getOne(), getTwo()])

参考文献

Generator 函数的含义与用法

co 函数库的含义和用法

async 函数的含义和用法

Generator 函数的语法